iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
Software Development

Python十翼:與未來的自己對話系列 第 23

[Day23] 八翼 - Scopes:常見錯誤1(LEGB原則)

  • 分享至 

  • xImage
  •  

八翼大綱

Python尋找變數的方法是透過LEGB,即LocalEnclosingGlobalBuilt-in scope,來層層尋找。有興趣深究的朋友,可以參考這篇Real Python的講解

雖然概念是清楚的,但仍然有一些需要注意的地方。本翼中,我們將分別以LEGB來代稱LocalEnclosingGlobalBuilt-in scope。

  • [Day23] 常見錯誤1(LEGB原則)。
  • [Day24] 常見錯誤2(global與nonlocal)。

UnBoundLocalError

UnBoundLocalError是一個常見的錯誤。# 01中,當unboundlocalerror_func被呼叫時,會raise UnboundLocalError。您可能會覺得疑惑,覺得print(x)會因為找不到Lx,而unboundlocalerror_func又沒有E,所以會在G中來尋找x,為什麼會報錯呢?

這是因為在unboundlocalerror_funcx = 2這個assignment。Python是在execute(或是想成compile)時就決定一個變數是不是local variable。由於unboundlocalerror_func中我們有指定x2,Python於execute(或compile)階段就會先認定x是一個local variable。接著當我們真正呼叫unboundlocalerror_func時,Python會開始依照LEGB的原則尋找變數。由於x已被認定是一個local variable,所以Python只會在unboundlocalerror_func這個L中尋找x,而我們的確於定義x前,就使用了print(x),所以會報錯。

# 01
x = 1


def unboundlocalerror_func():
    print(x)
    x = 2


unboundlocalerror_func()  # UnboundLocalError

Comprehension

在使用各種Comprehension時,也是一個容易出錯的地方。我們透過一連串小例子,慢慢說明。

暖身一下

# 02a中,當我們真正呼叫adders中每一個adder時,Python會依照LEGB原則尋找n。由於在L找不到n,又沒有E,最後在G中找到n,其值為3(不是10),因為在for n in range(1, 4)中,n最後於G中被指為3

# 02a
n = 10
adders = []

for n in range(1, 4):
    adders.append(lambda x: x+n)

for adder in adders:
    print(adder(1))  # 4 4 4

如果能理解# 02a的話,# 02b也是相同概念,最後於G中的n值為10

# 02b
adders = []

for n in range(1, 4):
    adders.append(lambda x: x+n)

n = 10

for adder in adders:
    print(adder(1))  # 11 11 11

list comprehension

我們將# 02a以list comprehension的方式改寫為# 02c,答案不變。

# 02c
n = 10
adders = [lambda x:x+n for n in range(1, 4)]
for adder in adders:
    print(adder(1))  # 4 4 4

但當我們將# 02b改寫為# 02d時,答案卻變了,這可能會讓你有點驚訝。

# 02d
adders = [lambda x:x+n for n in range(1, 4)]
n = 10
for adder in adders:
    print(adder(1))  # 4 4 4

事實上,comprehension內就像一個小的local scope(或可以想成一個namespace),# 02c# 02d可以改寫為# 02e,至於n = 10放前放後,並不影響答案。

# 02e
n = 10


def get_adders():
    adders = []
    for n in range(1, 4):
        def my_func(x):
            return x+n
        adders.append(my_func)
    return adders


adders2 = get_adders()
for adder in adders2:
    print(adder(1))  # 4 4 4

當我們呼叫每個adder時,由於L中找不到n,所以我們是在E這層找到n,其值為3,因為for n in range(1, 4)在最後將n指為3

修正寫法

如果我們想要每個adder都能獲得不同的n值,# 02f是一個可以參考的寫法。我們將n定義為lambdakeyword argument,並預設其值為n。這麼一來,於迴圈中我們就可以接受不同的n值了。

# 02f
n = 10
adders = [lambda x, n=n:x+n for n in range(1, 4)]
for adder in adders:
    print(adder(1))  # 2 3 4

Class Body

class body內取得變數時,也是一個容易讓人搞糊塗的地方,我們透過# 03來了解。

# 03
fruit = 'Apple'


class Basket:
    fruit = 'Orange'
    partition1 = [fruit] * 3
    partition2 = [fruit for _ in range(3)]

    def get_fruit(self):
        return f'{fruit}'


basket = Basket()
print(basket.get_fruit())  # Apple
print(basket.fruit)  # Orange
print(Basket.partition1)  # ['Orange', 'Orange', 'Orange']
print(basket.partition2)  # ['Apple', 'Apple', 'Apple']
  • basket.get_fruit()會返回Apple不是Orange,因為在function內,如果要取得classinstancevariable時,要使用類似selfcls等語法。所以當呼叫basket.get_fruit()時,L找不到fruit,又沒有E,所以找到的是GApple
  • basket.fruit會返回Orange,因為basket.__dict__中並沒有fruit,所以basket.fruit會往上到Basket中尋找。此時於Basket有找到fruit,所以返回其值。
  • Basket.partition1會返回['Orange', 'Orange', 'Orange']Basket.__dict__中有找到partition1,所以返回其值。由於partition1fruit同是class variable,所以[fruit] * 3實際上是將fruit其連續三次置入list中。
  • basket.partition2會返回['Apple', 'Apple', 'Apple']。因為basket.__dict__中並沒有partition2,所以basket.partition2會往上到Basket中尋找。此時於Basket找到partition2,所以返回其值。由於partition2使用list comprehension,所以相當於其在一個function底下,情況跟get_fruit是非常類似的,所以其最後找到的是,也會是GApple

牛刀小試

今天最後,我們來一個metaclassesscope的綜合練習題。

題意

寫一個metaclasses來生成如TargetClassclass

  • __init__接受任意數目的**kwargs
  • 自動將kwargs中的key加上_(underscore)後,設為instance variable,其值為原key所相對應的value
  • 自動以kwargs中的key,建立property,並返回相對應加上_(underscore)instance variable
# 04
class TargetClass:
    def __init__(self, **kwargs):
        """
        kwargs: {'x': 1, 'y':2, ...}
        """
        self.__dict__.update({f'_{k}': v for k, v in kwargs.items()})

    @property
    def x(self):
        return self._x

    @property
    def y(self):
        return self._y

    ...

解題思路

  • 首先我們讓MyType1繼承type
  • MyType1.__new__中使用super().__new__生成cls
  • 建立一個init function,其內部邏輯與上述TargetClass.__init__相同,並指定給cls.__init__
  • 針對kwargs打個迴圈,將其key依序設為property,並返回相對應的底層加上_(underscore)instance variable
  • 最後回傳cls

這麼一來我們就可以使用MyType1作為MyClass1metaclass,並搭配kwds作為MyType1.__new__的最後一個參數**kwargs,來生成MyClass1,及使用my_inst1 = MyClass1(**kwds)生成my_inst1

# 04
class MyType1(type):
    def __new__(mcls, cls_name, cls_bases, cls_dict, **kwargs):
        cls = super().__new__(mcls, cls_name, cls_bases, cls_dict)

        def init(self, **kwargs):
            self.__dict__.update({f'_{k}': v for k, v in kwargs.items()})
        cls.__init__ = init
        for prop in kwargs:
            setattr(cls,
                    prop,
                    property(lambda self: getattr(self, f'_{prop}')))
        return cls
    
    
kwds = {'x': 1, 'y': 2}


class MyClass1(metaclass=MyType1, **kwds):
    pass

my_inst1 = MyClass1(**kwds)

...

但是如果觀察my_inst1.xmy_inst1.y,卻發現其值都為2

>>> vars(my_inst1)  # {'_x': 1, '_y': 2}
>>> my_inst1.x, my_inst1.y  # 2 2

問題出在property(lambda self: getattr(self, f'_{prop}'))lambda self: getattr(self, f'_{prop}'是一個getter function,其接收的prop參數,是由for prop in kwargs而來。當我們真正使用my_inst1.xmy_inst1.y時,每個getter都會先在L中找prop,因為找不到,所以往外找。最後在E中找到prop,並認為propkwargs的最後一個key

修正解法

修正解法是使用keyword arguments,將property(lambda self: getattr(self, f'_{prop}'))改為property(lambda self, attr=prop: getattr(self, f'_{attr}'))。整體思路是與# 02f類似的,只是因為在metaclass內,語法比較複雜而已。

# 04
...

class MyType2(type):
    def __new__(mcls, cls_name, cls_bases, cls_dict, **kwargs):
        cls = super().__new__(mcls, cls_name, cls_bases, cls_dict)

        def init(self, **kwargs):
            self.__dict__.update({f'_{k}': v for k, v in kwargs.items()})
        cls.__init__ = init
        for prop in kwargs:
            setattr(cls,
                    prop,
                    property(lambda self, attr=prop: getattr(self, f'_{attr}')))
        return cls

kwds = {'x': 1, 'y': 2}

    
class MyClass2(metaclass=MyType2, **kwds):
    pass

my_inst2 = MyClass2(**kwds)

觀察my_inst2.x1,而my_inst2.y2,皆正確無誤。

>>> vars(my_inst2)  # {'_x': 1, '_y': 2}
>>> my_inst2.x, my_inst2.y  # 1 2

當日筆記

  • 當取用不在當前scope的變數時,要特別小心,因為此變數可能會被指派為其它值或被mutate,而發生預期外的行為。
  • 在迴圈中,指定function(或lambda)的參數時,keyword arguments可能是您的好幫手。

參考資料

Code

本日程式碼傳送門


上一篇
[Day22] 七翼 - Protocols:Context Manager Protocol
下一篇
[Day24] 八翼 - Scopes:常見錯誤2(global與nonlocal)
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言